<?php
/*
 *  $Id: Pessimistic.php 7490 2010-03-29 19:53:27Z jwage $
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * This software consists of voluntary contributions made by many individuals
 * and is licensed under the LGPL. For more information, see
 * <http://www.doctrine-project.org>.
 */

/**
 * Offline locking of records comes in handy where you need to make sure that
 * a time-consuming task on a record or many records, which is spread over several
 * page requests can't be interfered by other users.
 *
 * @package     Doctrine
 * @subpackage  Locking
 * @link        www.doctrine-project.org
 * @author      Roman Borschel <roman@code-factory.org>
 * @author      Pierre Minnieur <pm@pierre-minnieur.de>
 * @author      Konsta Vesterinen <kvesteri@cc.hut.fi>
 * @license     http://www.opensource.org/licenses/lgpl-license.php LGPL
 * @since       1.0
 * @version     $Revision: 7490 $
 */
class Doctrine_Locking_Manager_Pessimistic
{
	/**
	 * The conn that is used by the locking manager
	 *
	 * @var Doctrine_Connection object
	 */
	private $conn;

	/**
	 * The database table name for the lock tracking
	 */
	private $_lockTable = 'doctrine_lock_tracking';

	/**
	 * Constructs a new locking manager object
	 *
	 * When the CREATE_TABLES attribute of the connection on which the manager
	 * is supposed to work on is set to true, the locking table is created.
	 *
	 * @param Doctrine_Connection $conn The database connection to use
	 */
	public function __construct(Doctrine_Connection $conn)
	{
		$this->conn = $conn;

		if ($this->conn->getAttribute(Doctrine_Core::ATTR_EXPORT) & Doctrine_Core::EXPORT_TABLES) {
			$columns = array();
			$columns['object_type']        = array('type'    => 'string',
                                                   'length'  => 50,
                                                   'notnull' => true,
                                                   'primary' => true);

			$columns['object_key']         = array('type'    => 'string',
                                                   'length'  => 250,
                                                   'notnull' => true,
                                                   'primary' => true);

			$columns['user_ident']         = array('type'    => 'string',
                                                   'length'  => 50,
                                                   'notnull' => true);

			$columns['timestamp_obtained'] = array('type'    => 'integer',
                                                   'length'  => 10,
                                                   'notnull' => true);

			$options = array('primary' => array('object_type', 'object_key'));
			try {
				$this->conn->export->createTable($this->_lockTable, $columns, $options);
			} catch(Exception $e) {

			}
		}
	}

	/**
	 * Obtains a lock on a {@link Doctrine_Record}
	 *
	 * @param  Doctrine_Record $record     The record that has to be locked
	 * @param  mixed           $userIdent  A unique identifier of the locking user
	 * @return boolean  TRUE if the locking was successful, FALSE if another user
	 *                  holds a lock on this record
	 * @throws Doctrine_Locking_Exception  If the locking failed due to database errors
	 */
	public function getLock(Doctrine_Record $record, $userIdent)
	{
		$objectType = $record->getTable()->getComponentName();
		$key        = $record->getTable()->getIdentifier();

		$gotLock = false;
		$time = time();

		if (is_array($key)) {
			// Composite key
			$key = implode('|', $key);
		}

		try {
			$dbh = $this->conn->getDbh();
			$this->conn->beginTransaction();

			$stmt = $dbh->prepare('INSERT INTO ' . $this->_lockTable
			. ' (object_type, object_key, user_ident, timestamp_obtained)'
			. ' VALUES (:object_type, :object_key, :user_ident, :ts_obtained)');

			$stmt->bindParam(':object_type', $objectType);
			$stmt->bindParam(':object_key', $key);
			$stmt->bindParam(':user_ident', $userIdent);
			$stmt->bindParam(':ts_obtained', $time);

			try {
				$stmt->execute();
				$gotLock = true;

				// we catch an Exception here instead of PDOException since we might also be catching Doctrine_Exception
			} catch(Exception $pkviolation) {
				// PK violation occured => existing lock!
			}

			if ( ! $gotLock) {
				$lockingUserIdent = $this->_getLockingUserIdent($objectType, $key);
				if ($lockingUserIdent !== null && $lockingUserIdent == $userIdent) {
					$gotLock = true; // The requesting user already has a lock
					// Update timestamp
					$stmt = $dbh->prepare('UPDATE ' . $this->_lockTable
					. ' SET timestamp_obtained = :ts'
					. ' WHERE object_type = :object_type AND'
					. ' object_key  = :object_key  AND'
					. ' user_ident  = :user_ident');
					$stmt->bindParam(':ts', $time);
					$stmt->bindParam(':object_type', $objectType);
					$stmt->bindParam(':object_key', $key);
					$stmt->bindParam(':user_ident', $lockingUserIdent);
					$stmt->execute();
				}
			}
			$this->conn->commit();
		} catch (Exception $pdoe) {
			$this->conn->rollback();
			throw new Doctrine_Locking_Exception($pdoe->getMessage());
		}

		return $gotLock;
	}

	/**
	 * Releases a lock on a {@link Doctrine_Record}
	 *
	 * @param  Doctrine_Record $record    The record for which the lock has to be released
	 * @param  mixed           $userIdent The unique identifier of the locking user
	 * @return boolean  TRUE if a lock was released, FALSE if no lock was released
	 * @throws Doctrine_Locking_Exception If the release procedure failed due to database errors
	 */
	public function releaseLock(Doctrine_Record $record, $userIdent)
	{
		$objectType = $record->getTable()->getComponentName();
		$key        = $record->getTable()->getIdentifier();

		if (is_array($key)) {
			// Composite key
			$key = implode('|', $key);
		}

		try {
			$dbh = $this->conn->getDbh();
			$stmt = $dbh->prepare("DELETE FROM $this->_lockTable WHERE
                                        object_type = :object_type AND
                                        object_key  = :object_key  AND
                                        user_ident  = :user_ident");
			$stmt->bindParam(':object_type', $objectType);
			$stmt->bindParam(':object_key', $key);
			$stmt->bindParam(':user_ident', $userIdent);
			$stmt->execute();

			$count = $stmt->rowCount();

			return ($count > 0);
		} catch (PDOException $pdoe) {
			throw new Doctrine_Locking_Exception($pdoe->getMessage());
		}
	}

	/**
	 * Gets the unique user identifier of a lock
	 *
	 * @param  string $objectType  The type of the object (component name)
	 * @param  mixed  $key         The unique key of the object. Can be string or array
	 * @return string              The unique user identifier for the specified lock
	 * @throws Doctrine_Locking_Exception If the query failed due to database errors
	 */
	private function _getLockingUserIdent($objectType, $key)
	{
		if (is_array($key)) {
			// Composite key
			$key = implode('|', $key);
		}

		try {
			$dbh = $this->conn->getDbh();
			$stmt = $dbh->prepare('SELECT user_ident FROM ' . $this->_lockTable
			. ' WHERE object_type = :object_type AND object_key = :object_key');
			$stmt->bindParam(':object_type', $objectType);
			$stmt->bindParam(':object_key', $key);
			$success = $stmt->execute();

			if ( ! $success) {
				throw new Doctrine_Locking_Exception("Failed to determine locking user");
			}

			$userIdent = $stmt->fetchColumn();
		} catch (PDOException $pdoe) {
			throw new Doctrine_Locking_Exception($pdoe->getMessage());
		}

		return $userIdent;
	}

	/**
	 * Gets the identifier that identifies the owner of the lock on the given
	 * record.
	 *
	 * @param Doctrine_Record $lockedRecord  The record.
	 * @return mixed The unique user identifier that identifies the owner of the lock.
	 */
	public function getLockOwner($lockedRecord)
	{
		$objectType = $lockedRecord->getTable()->getComponentName();
		$key        = $lockedRecord->getTable()->getIdentifier();
		return $this->_getLockingUserIdent($objectType, $key);
	}

	/**
	 * Releases locks older than a defined amount of seconds
	 *
	 * When called without parameters all locks older than 15 minutes are released.
	 *
	 * @param  integer $age  The maximum valid age of locks in seconds
	 * @param  string  $objectType  The type of the object (component name)
	 * @param  mixed   $userIdent The unique identifier of the locking user
	 * @return integer The number of locks that have been released
	 * @throws Doctrine_Locking_Exception If the release process failed due to database errors
	 */
	public function releaseAgedLocks($age = 900, $objectType = null, $userIdent = null)
	{
		$age = time() - $age;

		try {
			$dbh = $this->conn->getDbh();
			$stmt = $dbh->prepare('DELETE FROM ' . $this->_lockTable . ' WHERE timestamp_obtained < :age');
			$stmt->bindParam(':age', $age);
			$query = 'DELETE FROM ' . $this->_lockTable . ' WHERE timestamp_obtained < :age';
			if ($objectType) {
				$query .= ' AND object_type = :object_type';
			}
			if ($userIdent) {
				$query .= ' AND user_ident = :user_ident';
			}
			$stmt = $dbh->prepare($query);
			$stmt->bindParam(':age', $age);
			if ($objectType) {
				$stmt->bindParam(':object_type', $objectType);
			}
			if ($userIdent) {
				$stmt->bindParam(':user_ident', $userIdent);
			}
			$stmt->execute();

			$count = $stmt->rowCount();

			return $count;
		} catch (PDOException $pdoe) {
			throw new Doctrine_Locking_Exception($pdoe->getMessage());
		}
	}
}
